7

前端开发过程中会接触各种各样的编码,比较常见的主要是 UTF-8 和 HTML 实体编码,但是 web 前端的世界却不止这两种编码,而且编码的选择也会造成一定的问题,如前后端开发过程中不同编码的兼容、多字节编码可能会造成的 XSS 漏洞等。因此,本文旨在更好的全面了解涉及前端开发领域的字符编码,避免可能出现的交互和开发中的忽视的漏洞。

URL 编码

我曾经在 URL 编码解码和 base64 一文中讲述了 URL 编码中的三组函数,并对比了这三组函数与 base64 编码的关系,在此简要说明一下。

escape/unescape 函数针对宽字符做 unicode 编码,并针对码值做十六进制编码,所以使用 escape 针对汉字编码会得到形如 \uxxxx 的结果;encodeURI/decodeURI, encodeURIComponent/decodeURIComponent 函数针对宽字节编码却不同于 escape,首先针对宽字节字符进行 UTF-8 编码,然后针对编码后的结果进行 替换,得到结果。以上所述都是针对宽字节而言,对于编码靠前的 ASCII 字符而言,上述三组函数的安全字符的范围也有所不同,具体可在上文中了解。

base64 编码

base64 编码在前端通常用于图片和 icon 的编码,它将每 3 个 8 位字节为一组,分成 4 组 6 位字节,并且每个字节的高位补零,形成 4 个 8 位的字节,由此可看出 base64 编码是可逆推的。在大多数浏览器中,提供了 ASCII 字符的 base64 编码函数,即 window.btoa()。该函数无法针对宽字节进行base64编码,若针对中文编码,则需现转换位 UTF-8 编码,然后进行 base64 编码。

function unicodeToBase64(s){
    return window.btoa(unescape(encodeURIComponent(s)))
  }

通过 encodeURIComponent 对宽字节字符编码,是 %xx 形式的编码,与 UTF-8 编码的区别仅在于前缀(这是由规范 RFC3986 决定的,将非 ASC 字符进行某种形式编码,并转换为 16 进制,并在字节前加上“%”)。因此通过 unescape(encodeURIComponent(s)) 可以转化为 UTF-8 字节。当然,也可自己写一个转换函数,按照一定规则便行为 UTF-8 编码的字节,如下例:

unescape(encodeURIComponent("中国")) //结果:"中国"
encodeURIComponent("中国") //结果:"%E4%B8%AD%E5%9B%BD"
console.log("\u00E4\u00B8\u00AD\u00E5\u009B\u00BD") // 结果: "中国"

通过简单的 replace 函数,就可以完成 URL 编码到 UTF-8 编码的转换,进而完成宽字节字符到base64编码的转换。有了这个函数,我们手动生成一些 data URI 形式的内容,只需制定 MIME 类型和编码方式,就可以实现文本的转换,如以下代码:

<a href="data:text/html;charset=utf-8;base64,PHNjcmlwdD5hbGVydCgxMik8L3NjcmlwdD4=" >abc</a>
// 未编码前:<a href="javascript: alert(1)">test</a>

前端 UTF-8 编码与后端 GBK 编码的兼容

目前前端大都采用 UTF-8 进行编码,不管是 html、js 抑或是 css,而后端则由于历史原因大都采用 GBK 或 GB2312 进行解码,因此前端通过 parameter 传递的 URL 编码的字符串就不可能直接在后台进行解码,为了更好的兼容性,前端可进行两次 URL 编码,即 encodeURIComponent(encodeURIComponent(“中国”)),这样后端接收到参数后,先使用 GBK 或 GB2312 解码,得到了 UTF-8 编码后再使用 UTF-8 解码即可。两次编码主要是利用“ASC 字符使用 GBK 或 GB2312 编码不变”的特点完成,富有技巧。

HTML 实体编码与进制编码

实体编码针对HTML的预留字符而言,如 <> 等。实体编码有两种形式 &实体名;&entity_number;,由于浏览器对 &实体名; 的兼容性有差别,因此最好采用实体号的形式编码。

进制编码,顾名思义将ASC字符对应的码值按照十六进制或十进制编码,并转化为 &#x;(16进制)&#D;(10进制) 形式。

单单针对实体编码而言并没有什么特殊强调的点,之所以把它单独列为一个章节,意在强调这两种编码与 js 代码的作用域的关系。

1、<div onclick="document.write('<img src=1 onerror=alert(23)>')">cccc</div> 
2、<div onclick="document.write('&lt;img src=1 onerror=alert(23)&gt;')">cccc</div>
3、&#x3c;&#x69;&#x6d;&#x67;&#x20;&#x73;&#x72;&#x63;&#x3d;&#x31;&#x20;&#x6f;&#x6e;&#x65;&#x72;&#x72;&#x6f;&#x72;&#x3d;&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x32;&#x33;&#x29;&#x3e;
4、<img src=1 onerror=&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x32;&#x33;&#x29;>

<script>
    5、document.write('&lt;img src=1 onerror=alert(23)&gt;');
    6、document.write('<img src=1 onerror=&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x33;&#x29;>');
    7、document.write('&#x3c;&#x69;&#x6d;&#x67;&#x20;&#x73;&#x72;&#x63;&#x3d;&#x31;&#x20;&#x6f;&#x6e;&#x65;&#x72;&#x72;&#x6f;&#x72;&#x3d;&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x32;&#x33;&#x29;&#x3e;')
    8、document.write('\u003c\u0069\u006d\u0067\u0020\u0073\u0072\u0063\u003d\u0031\u0020\u006f\u006e\u0065\u0072\u0072\u006f\u0072\u003d\u0061\u006c\u0065\u0072\u0074\u0028\u0032\u0033\u0029\u003e')
</script>

代码中列举了 8 个例子:

  • 第一个在事件处理函数 onclick 中输出 HTML 片段;

  • 第二个则输出经实体编码后的 HTML 片段;

  • 第三个则是直接针对 <img src=1 onerror=alert(23)> 做 16 进制编码;

  • 第四个则是针对 onerror 事件处理函数做 16 进制编码;

  • 第五个则是在脚本中输出实体编码的字符;

  • 第六个针对事件处理函数做 16 进制编码;

  • 第七个则针对所有的字符做 16 进制编码;

  • 第八个则是在 script 中直接输出 <img src=1 onerror=alert(23)> 的 unicode 编码

对比结果,前两个例子在点击后都会弹出 alert;第三个例子则在页面中显示文本 <img src=1 onerror=alert(23)>;第四个例子则会在页面加载初期弹出 alert;第五、七会输出字符串;第六、八则会在第四个例子中的 alert 之后也弹出 alert。现在分析这些结果,通过第一二个例子可知道,HTML 标签中(除 script 标签)的内联 js 代码可以进行 HTML 实体编码,这是非常重要的一点,我们可以更为明确的进行验证:

<div onclick="alert('&lt;img src=1 onerror=alert(23)&gt;')">cccc</div>

输出的结果自然是 <img src=1 onerror=alert(23)>,这的确论证了我们上文提到的这一点;第三个例子说明了HTML解析器在进行词法分析前,首先进行解码,十六进制和十进制皆可,因此,结果自然输出形如 <img src=1 onerror=alert(23)> 的字符串;第四个例子则紧接着论证了内联在 HTML 的并采用十六进制编码的 js 代码同样会被正确解析并执行,这说明了进制编码同样可被 HTML 解析器解析;第五、七个例子说明在 js 中同样可以使用实体编码和进制编码,解析的结果会渲染在页面上;第六个例子则论证了上一观点,只针对事件处理函数做进制编码,执行后页面弹出 alert;第八个例子则是在 js 中执行 unicode 编码的字符串,正常 alert。

由此可见,js 代码内联在 HTML 的非 script 标签内,则会遵守 HTML 编码规范:进制编码和实体编码;而在 js 代码(script 标签内以及 js 文件内)中,则遵从 js 编码:1. unicode 形式编码(uxxxx) 2. 普通的 16 进制编码(xH),这可通过第八个例子得到证明。之所以在本节提到这么多编码特点,主要提醒大家在预防 XSS 时需要注意的几点:

  • 检测用户输入时,不仅仅需要防范类似 <> 这样的字符,通过 unicode 编码或进制编码仍有可能注入代码

  • 需要针对特定的关键字做过滤,如 eval、write、prototype

  • 尽可能禁止内联事件处理函数的使用

  • js 过滤 src/href/action 属性,如 javascript:, data:

JS 编码

其实在上节中已提到了 js 编码,即 js 可执行 unicode 编码和十六(八)进制编码后的字符串,但是不支持十进制编码的字串。具体操作可通过常用的几个函数来实现,如 eval,write,setTimeout,Function 执行编码后的字符串;同样,对于十进制编码的字串,通过结合 String.fromCharCodeeval 同样可以执行。

在此附上笔者实现的字符转换,更为灵活的实现各种自定义形式的字串编码:

var Code = {};
    /**
     *
     * @param str 待编码字串
     * @param jinzhi 进制编码
     * @param prefix 前缀
     * @param postfix 后缀
     * @param count 总共编码的位数,默认为4
     * @returns {string}
     */
    Code.encode = function({str = '',jinzhi = '16',prefix = '\\u',postfix = ';',count = '4'} = {}){
        var ret = '';
        var addZero,tmp;
        for(let i=0;i<str.length;i++){
            tmp = str.charCodeAt(i).toString(jinzhi);
            addZero = count - tmp.length + 1;
            ret += prefix + new Array(addZero).join('0') + tmp + postfix;
        }
        return ret;
    };
    Code.decode = function({str = '',jinzhi = '16',prefix = '\\u',postfix = ';'} = {}){
        var ret = '';
        var splits = str.split(';');
        for(let i=0;i<splits.length;i++){
            let tmp = splits[i].replace(prefix,'');
            ret += String.fromCharCode(parseInt(tmp,jinzhi));
        }
        return ret;
    };

    console.log(Code.encode({str: '<img src=@ onerror=alert(123) />'}));
    console.log(Code.decode({str: Code.encode({str: '<img src=@ onerror=alert(123) />'})}))

另外,对于 js 输出点的过滤其实并不仅限于上文提到的如 eval、setTimeout、Function 等几个,由于 JS 语法比较灵活相对“漏洞”较多,可使用的“线索”也越丰富,如前段时间在 Stackoverflow 上发现的一个问题,即

(0)['constructor']['constructor']('return "abc;"')()

同样可以执行 JS 代码,确实挺有特点的,具体为什么上述形式可以执行代码,请读者自己仔细品味。


RoyalRover
761 声望52 粉丝

前端、区块链、node.js/goalang编程